{
"cells": [
{
"cell_type": "markdown",
"id": "cf64def9",
"metadata": {},
"source": [
"# Neurale netværk og digitale billeder\n",
"\n",
"Dette materiale er en tilpasset udgave af [Intermat 2.0 modul 4](https://intermat20.compute.dtu.dk/ct/Modul4_elever.html)."
]
},
{
"cell_type": "markdown",
"id": "e94c0e01",
"metadata": {},
"source": [
"## 0: Opsætning"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9756cb4d",
"metadata": {},
"outputs": [],
"source": [
"# pip install scikit-learn scikit-image\n",
"from skimage import data, img_as_float\n",
"from sklearn.datasets import load_digits\n",
"from sklearn.preprocessing import OneHotEncoder\n",
"from sklearn.model_selection import train_test_split\n",
"from matplotlib.pyplot import imshow, subplots\n",
"import matplotlib.pyplot as plt \n",
"from gym_cas import plot, x,y, Piecewise, plot_vector\n",
"from spb import plot3d, plot_contour, plot3d_list\n",
"import numpy as np"
]
},
{
"cell_type": "markdown",
"id": "a32b6803",
"metadata": {},
"source": [
"## 1: Digitalt billede som matrix"
]
},
{
"cell_type": "markdown",
"id": "a3d20f2d",
"metadata": {},
"source": [
"Vi importerer et billede:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7d773223",
"metadata": {},
"outputs": [],
"source": [
"img = img_as_float(data.camera())\n",
"imshow(img, cmap=\"gray\", vmin=0, vmax=1)"
]
},
{
"cell_type": "markdown",
"id": "25f80eb1",
"metadata": {},
"source": [
"> Hvordan ser man dette billede som en matrix i Python? Hvad er størrelsen af matricen, hvilke værdier indgår i matricen og \n",
"hvad angiver værdierne i matricen?\n",
"\n",
"Hint: `np.array` og `.shape`."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "265c230c",
"metadata": {},
"outputs": [],
"source": [
"# Convert to numpy array and inspect size\n",
"# Add CODE HERE"
]
},
{
"cell_type": "markdown",
"id": "efeee5c5",
"metadata": {},
"source": [
"\n",
"Svar
\n",
"\n",
"Værdierne i matricen er decimal-tal normaliseret til intervallet [0, 1], hvor hver værdi angiver gråtone‑intensiteten i den tilsvarende pixel (0 = sort, 1 = hvid). Matricens dimensioner svarer til billedets højde og bredde (512×512). \n",
"\n",
"Du kan copy-paste denne kode til et Python-vindue:\n",
"```python\n",
"# Convert to numpy array and inspect size\n",
"\n",
"arr = np.array(img)\n",
"print(\"shape:\", arr.shape, \"dtype:\", arr.dtype)\n",
"```\n",
" "
]
},
{
"cell_type": "markdown",
"id": "8bf6e012",
"metadata": {},
"source": [
"> Zoom ind på de $8 \\times 8$ pixel i øverste, venstre hjørne og udskriv pixel-værdierne som en $8 \\times 8$ matrix. Hvad bemærker man ved de 64 gråtone-værdier?"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "2f29a2c8",
"metadata": {},
"outputs": [],
"source": [
"display(\"Top-left 8×8 block:\")\n",
"# ADD CODE HERE"
]
},
{
"cell_type": "markdown",
"id": "4e63a018",
"metadata": {},
"source": [
"\n",
"Svar
\n",
"\n",
"Det bemærkes, at de 64 gråtoneværdier i det øverste venstre hjørne er næsten konstante, hvilket tyder på et homogent område uden mange detaljer (fx en \"ensfarvet\" flade). Det ses også i det zoomede billede, hvor nabopixel kun varierer lidt, hvilket indikerer lav lokal kontrast i den pågældende region. Det gælder generelt for de fleste naturlige billeder af høj opløsning, at der er en høj grad af **spatial korrelation**: Værdien af en pixel er ofte meget tæt på værdien af dens nabopixeler. Denne redundans (overflødige information) er en fundamental egenskab, der udnyttes i billedkomprimeringsformater som JPEG. I stedet for at gemme den præcise værdi for hver enkelt pixel, kan man mere effektivt gemme en basisværdi og de små forskelle til nabopixelerne.\n",
"\n",
"```python\n",
"print(\"Top-left 8×8 block:\")\n",
"print(arr[:8, :8])\n",
"\n",
"imshow(arr[:8, :8], cmap=\"gray\", vmin=0, vmax=1)\n",
"```\n",
" "
]
},
{
"cell_type": "markdown",
"id": "2d613f41",
"metadata": {},
"source": [
"> Zoom nu ind på de $8 \\times 8$ pixel i nederste, højre hjørne og vis dit zoom som et (pixeleret) billede"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "246e8994",
"metadata": {},
"outputs": [],
"source": [
"# ADD CODE HERE"
]
},
{
"cell_type": "markdown",
"id": "c3e340bb",
"metadata": {},
"source": [
"\n",
"Svar
\n",
"\n",
"```python\n",
"print(\"Bottom-right 8×8 block:\")\n",
"print(arr[-8:, -8:])\n",
"\n",
"imshow(arr[-8:, -8:], cmap=\"gray\", vmin=0, vmax=1)\n",
"```\n",
"\n",
" "
]
},
{
"cell_type": "markdown",
"id": "0b693221",
"metadata": {},
"source": [
"## 2: Digitalt billede som vektor"
]
},
{
"cell_type": "markdown",
"id": "a076941c",
"metadata": {},
"source": [
"Vi skal arbejde med automatisk genkendelse af håndskrevne tal fra `sklearn.datasets.load_digits`:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "26772187",
"metadata": {},
"outputs": [],
"source": [
"digits_object = load_digits() \n",
"X = digits_object.data # fetch data\n",
"Y = digits_object.target # fetch labels\n",
"X = X / 16.0 # normalize to [0, 1]\n",
"\n",
"# Show some examples\n",
"fig, axes = subplots(2, 5, figsize=(8, 3))\n",
"for ax, img, label in zip(axes.ravel(), X, Y):\n",
" ax.imshow(img.reshape(8, 8), cmap=\"gray\", vmin=0, vmax=1)\n",
" ax.set_title(f\"Label: {label}\")\n",
" ax.axis(\"off\")"
]
},
{
"cell_type": "markdown",
"id": "1d13a923",
"metadata": {},
"source": [
"> Kan du se hvilke håndskrevne tal der er tale om? Tjek med de rigtige labels.\n",
"\n",
"\n",
"Vi vil i dag gerne bygge et neuralt netværk $\\Phi : \\mathbb{R}^n \\to \\mathbb{R}^k$ der kan genkende disse håndskrevne tal. Denne \"AI-funktion\" tager altså en vektor i $\\mathbb{R}^n$ som input og giver en vektor i $\\mathbb{R}^k$ som output.\n",
"\n",
"Vores input er umiddelbart en $8 \\times 8$ matrix:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "fbcae771",
"metadata": {},
"outputs": [],
"source": [
"example_img = X[0].reshape(8, 8) # image as 8x8 matrix\n",
"example_img"
]
},
{
"cell_type": "markdown",
"id": "ca27b973",
"metadata": {},
"source": [
"> Hvordan kan vi få det lavet om til en vektor?"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "582d11ce",
"metadata": {},
"outputs": [],
"source": [
"# ADD CODE HERE"
]
},
{
"cell_type": "markdown",
"id": "eefbfe9f",
"metadata": {},
"source": [
"\n",
"Svar
\n",
"\n",
"```python\n",
"# flatten matrix into vector\n",
"example_img = X[0].reshape(8, 8) # first image as 8x8 matrix\n",
"example_vec = example_img.flatten() # flatten to vector of length 64\n",
"\n",
"print(\"Matrix shape:\", example_img.shape)\n",
"print(\"Vector shape:\", example_vec.shape)\n",
"example_img, example_vec\n",
"```\n",
"\n",
" \n",
"\n",
"Input til vores AI-funktion $\\Phi$ er håndskrevne tal (repræsenteret som en vektor i $\\mathbb{R}^n$). Vi ønsker at output af $\\Phi$ skal være en sandsynlighedsvektor af længde 10 med sandsynligheden for at det håndskrevne tal er hhv. 0, 1, 2, ..., 9. \n",
"\n",
"```{note}\n",
"En sandsynlighedsvektor er en vektor af tal tilhørende [0,1], hvis elementer summer til 1 (altså en diskret sandsynlighedsfordeling over klasserne).\n",
"```\n",
"\n",
"> Hvad er $n$ og $k$ i vores eksempel. Hvad er det ideelle output af funktionen $\\Phi$, hvis inputtet er et håndskrevet 8-tal?\n",
"\n",
"\n",
"\n",
"Svar
\n",
"n = 64 (8×8 pixels fladet ud)\n",
"k = 10 (klasserne 0–9)\n",
"\n",
"Ideelt output for et 8-tal: en \"one‑hot\" sandsynlighedsvektor af længde 10 med 1 ved indeks 8, f.eks. [0,0,0,0,0,0,0,0,1,0] (husk at vi bruger 0‑baseret indeksering — altså er 1-tallet placeret ved indeks 8 (den niende komponent)\n",
"\n",
" \n",
"\n",
"Den (indtilvidere ukendte) AI-funktion $\\Phi$ opbygges som et neuralt netværk. De næste opgaver introducerer byggestenene i sådanne funktioner."
]
},
{
"cell_type": "markdown",
"id": "a0c914ed",
"metadata": {},
"source": [
"## 3: ReLU-funktionen"
]
},
{
"cell_type": "markdown",
"id": "426867b9",
"metadata": {},
"source": [
"> Definer ReLU funktionen $\\mathrm{ReLU}: \\mathbb{R} \\to \\mathbb{R}$ i Python. Plot grafen af funktionen."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "ddd3f0c1",
"metadata": {},
"outputs": [],
"source": [
"def relu(z):\n",
" return Piecewise((0, True)) # FIX CODE HERE\n",
"\n",
"plot(relu(x), title=\"ReLU\", xlabel=\"x\", ylabel=\"ReLU(x)\")"
]
},
{
"cell_type": "markdown",
"id": "747c1e97",
"metadata": {},
"source": [
"\n",
"Svar
\n",
"\n",
"```python\n",
"def relu(z):\n",
" return Piecewise((0, z < 0), (z, z >= 0))\n",
"```\n",
" "
]
},
{
"cell_type": "markdown",
"id": "da02e65e",
"metadata": {},
"source": [
"> Forklar hvad der sker i følgende kode."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7db60e30",
"metadata": {},
"outputs": [],
"source": [
"def relu(arr):\n",
" return np.maximum(0.0, arr)\n",
"\n",
"vec = np.array([-3, -1, 0, 1, 3])\n",
"print(\"ReLU on vector:\", relu(vec)) "
]
},
{
"cell_type": "markdown",
"id": "f911897a",
"metadata": {},
"source": [
"\n",
"Svar
\n",
"\n",
"ReLU defineres som en skalar-funktion af en variabel, men vi kan nemt udvide den til en vektor-funktion ved at lade den virke på en vektor **koordinatvis**, hvilket Python gør helt automatisk når der bruges numpy.\n",
"\n",
" "
]
},
{
"cell_type": "markdown",
"id": "fe8f4b4b",
"metadata": {},
"source": [
"> Er ReLU funktionen lineær? Er den kontinuert? Udregn den afledte. Er den differentiabel? \n",
"\n",
"\n",
"Hint
\n",
"\n",
"En lineær funktion opfylder $f(x+y)=f(x)+f(y)$ og $f(a x)=a f(x)$ for alle $x,y$ og alle skalarer $a$.\n",
"\n",
" \n",
"\n",
"\n",
"Svar
\n",
"\n",
"ReLU er ikke lineær: den opfylder fx ikke $f(-1)=-f(1)$.\n",
"\n",
"ReLU er kontinuert (ingen spring), men kun stykkevis-lineær. Den afledte er\n",
"- $f'(x)=0$ for $x<0$\n",
"- $f'(x)=1$ for $x>0$\n",
"og er ikke differentiabel i $x=0$ (funktionens eneste ikke-differentiable punkt). (Det er muligt at tale om en subgradient i $x=0$, der er hvilket som helst tal i [0,1].)\n",
"\n",
" "
]
},
{
"cell_type": "markdown",
"id": "a6843817",
"metadata": {},
"source": [
"## 4: Gradienten\n",
"\n",
""
]
},
{
"cell_type": "markdown",
"id": "a5871185",
"metadata": {},
"source": [
"Vi ønsker at **finde minimum** af en funktion\n",
"\n",
"\\begin{equation*}\n",
"f:\\mathbb{R}^2 \\to \\mathbb{R}, \\qquad\n",
"f(x,y) = 3(x-1)^2 + y^2 + 4.\n",
"\\end{equation*}\n",
"\n",
"Vi tegner først grafen for funktionen. I SymPy gøres det ved:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "73126115",
"metadata": {},
"outputs": [],
"source": [
"def f(x,y):\n",
" return 3*(x-1)**2 + y**2 + 4\n",
"\n",
"plot3d(f(x,y), (x, -1, 3), (y, -2, 2), title=\"Graph of f\")"
]
},
{
"cell_type": "markdown",
"id": "b232d84d",
"metadata": {},
"source": [
"### a: Gradient-vektoren"
]
},
{
"cell_type": "markdown",
"id": "4eaff739",
"metadata": {},
"source": [
"Vi betragter funktionen $f : \\mathbb{R}^2 \\to \\mathbb{R}$ givet ved:\n",
"\n",
"\\begin{equation*}\n",
"f(x,y)=3(x-1)^2 + y^2 + 4\n",
"\\end{equation*}\n",
"\n",
"Gradient-vektoren består af de partielle afledte:\n",
"\n",
"\\begin{equation*}\n",
"\\nabla f(x,y) =\n",
"\\begin{bmatrix}\n",
"\\frac{\\partial f}{\\partial x} \\\\\n",
"\\frac{\\partial f}{\\partial y}\n",
"\\end{bmatrix}.\n",
"\\end{equation*}\n",
"\n",
"hvilket også skrives $\\nabla f(x,y) = [\\frac{\\partial f}{\\partial x}, \\frac{\\partial f}{\\partial y}]^T$ - her betyder T'et blot at vi tager den *transponerede* af rækkevektoren, hvilket omdanner den til en søjlevektor. \n",
"\n",
"\n",
"> Find gradient-vektoren for $f$. \n",
"\n",
"\n",
"Svar
\n",
"\n",
"Udregn:\n",
"\n",
"\\begin{equation*}\n",
"\\frac{\\partial f}{\\partial x} = 6(x-1),\\qquad\n",
"\\frac{\\partial f}{\\partial y} = 2y.\n",
"\\end{equation*}\n",
"\n",
"Altså:\n",
"\n",
"\\begin{equation*}\n",
"\\nabla f(x,y) =\n",
"\\begin{bmatrix}\n",
"6(x-1)\\\\[4pt]\n",
"2y\n",
"\\end{bmatrix}.\n",
"\\end{equation*}\n",
"\n",
" \n",
"\n",
"### b: Gradient-vektoren og niveau-kurver"
]
},
{
"cell_type": "markdown",
"id": "7bfd956a",
"metadata": {},
"source": [
"Niveaukurver for en funktion $f$ er mængden af punkter $(x,y)$ hvor $f(x,y)=c$ for et givet reelt tal $c$. De kan afbildes vha. [`plot_contour`](https://sympy-plot-backends.readthedocs.io/en/latest/modules/plot_functions/functions_2d.html#spb.plot_functions.functions_2d.plot_contour) fra SPB (sympy-plot-backends).\n",
"\n",
"> Plot gradient-vektoren i et valgfrit punkt $(x_0,y_0)$ (evt. nedskaleret). Plot niveau-kurven gennem samme punkt. Brug evt. nedenstående kode som udgangspunkt."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d80cbebd",
"metadata": {},
"outputs": [],
"source": [
"p1 = plot_contour(x+y, (x, -2, 2), (y, -2, 2), is_filled=False, show=False)\n",
"p2 = plot_vector((0, 0), (1, 1), show=False)\n",
"(p1 + p2).show()"
]
},
{
"cell_type": "markdown",
"id": "97ac72e7",
"metadata": {},
"source": [
"### c: Minimum"
]
},
{
"cell_type": "markdown",
"id": "cbc2071c",
"metadata": {},
"source": [
"I hvilket punkt $(x,y)$ antager funktionen sin minimumsværdi?\n",
"\n",
"\n",
"Svar
\n",
"\n",
"Fra grafen af funktionen kan vi se, at funktionen har et minimum, og fra plottet af niveaukurverne kan man aflæse hvor funktionen antager sin minimumsværdi. Man kan også \"regne\" sig frem til det: I et sådant mimimumspunkt er der ingen retning hvor funktionen vokser, og derfor må gradient-vektoren være nul-vektoren. Vi kan altså finde minimum ved at løse ligningen $\\nabla f(x,y) = \\mathbf{0}$.\n",
"\n",
"Dette giver os de to ligninger:\n",
"\n",
"\\begin{equation*}\n",
"6(x-1)=0 \\quad \\text{og} \\quad 2y=0\n",
"\\end{equation*}\n",
"\n",
"Den eneste løsning til disse ligninger er punktet $(x,y)=(1,0)$. Dette er derfor funktionens **minimumspunkt**.\n",
"\n",
"Punkter hvor gradient-vektoren er nulvektoren kaldes stationære punkter, og sådan punkter kan være (lokale) minimumspunkter, maksimumspunkter eller såkaldte sadelpunkter.\n",
"\n",
" \n",
"\n",
"### d: Fortolkning"
]
},
{
"cell_type": "markdown",
"id": "ef2c8274",
"metadata": {},
"source": [
"> Hvordan skal man forstå (det korrekte) udsagn: \"Gradienten $\\nabla f(x,y)$ peger i den retning (fra punktet $(x,y)$), hvor funktionen vokser hurtigst\"? Er det korrekt at $-\\nabla f(x,y)$ peger i den retning, hvor funktionen aftager hurtigst? \n",
"\n",
"\n",
"Svar
\n",
"\n",
"Ja, begge udsagn er korrekte. Gradienten $\\nabla f(x,y)$ er en vektor i $(x,y)$-planen, der peger i den retning, man skal bevæge sig for at opnå den hurtigste stigning i funktionsværdien. Modsat peger den negative gradient, $-\\nabla f(x,y)$, i retningen for det hurtigste fald.\n",
"\n",
"Det er vigtigt at huske, at gradienten kun giver **lokal** information. Den fortæller os om den stejleste retning lige præcis i det punkt, vi står i (ud fra tangentplanen i punktet).\n",
"\n",
"**Eksempel:** For funktionen $f(x,y) = 3(x-1)^2 + y^2 + 4$ i punktet $(x,y)=(2,1)$ er gradienten $\\nabla f(2,1) = [6, 2]^T$. Den negative gradient er derfor $-\\nabla f(2,1) = [-6, -2]^T$. Hvis vi står i punktet $(2,1)$ og vil finde funktionens minimum, er den lokalt bedste retning at bevæge sig i retningen $[-6, -2]^T$ (eller en hvilken som helst positiv skalering af den, f.eks. $[-3, -1]^T$). Vi ved dog ikke, hvor *langt* vi skal bevæge os i denne retning.\n",
"\n",
" \n",
"\n",
"> Peger den negative gradient altid præcist mod funktionens minimum? \n",
"\n",
"\n",
"Svar
\n",
"\n",
"Nej. Negative gradient peger kun i den **lokale** retning for hurtigst fald og giver ingen global information. \n",
"\n",
" "
]
},
{
"cell_type": "markdown",
"id": "51a4e647",
"metadata": {},
"source": [
"## 5: Netværkslandskabet: Grafen af et neuralt netværk"
]
},
{
"cell_type": "markdown",
"id": "c6b08ee0",
"metadata": {},
"source": [
"I denne opgave skal vi visualisere og fortolke stykkevist-lineære funktioner skabt af ReLU-net, hvor input dimensionen er $2$ og output dimensionen er $1$. Det drejer sig altså om funktioner af formen $\\Phi: \\mathbb{R}^2 \\to \\mathbb{R}$. Grafen for et neuralt netværk (netværks-funktionen $\\Phi$) kaldes netværkslandskabet. \n",
"\n",
"Vi betragter her kun tynde (shallow) netværk, hvor der kun er ét skjult lag. Arkitekturen for vores netværk kan beskrives med notationen $2 \\to n \\to 1$ (eller som $\\mathbb{R}^2 \\to \\mathbb{R}^n \\to \\mathbb{R}^1$). Denne notation angiver antallet af neuroner i hvert lag:\n",
"- **2** neuroner i **input-laget**, svarende til de to input-variable $(x_1, x_2)$ som er en (søjle)vektor i $\\mathbb{R}^2$. \n",
"- **$n$** neuroner i det **skjulte lag**, hvis output kan tænkes på som en søjlevektor i $\\mathbb{R}^n$. Dette tal, $n$, kan vi variere for at se, hvordan det påvirker netværkets kompleksitet.\n",
"- **1** neuron i **output-laget**, som producerer den endelige output-værdi $z=\\Phi(x_1, x_2)$ som her blot er et tal i $\\mathbb{R}^1$. I output-laget bruges der typisk ingen aktiveringsfunktion som fx ReLU, da ReLU ville umuliggøre negative output-værdier.\n",
"\n",
"Vi skal forstå sammenhængen mellem antallet af neuroner i det skjulte lag, $n$, og \"kompleksiteten\" af netværkslandskabet. \n",
"\n",
"Det neurale netværk bygges af følgende funktioner:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e11ff93d",
"metadata": {},
"outputs": [],
"source": [
"def relu(z):\n",
" return np.maximum(0.0, z)\n",
"\n",
"def sample_weights(n):\n",
" W1 = np.random.randn(n, 2)\n",
" b1 = 0.2 * np.random.randn(n, 1)\n",
" W2 = np.random.randn(1, n)\n",
" b2 = 0.2 * np.random.randn(1, 1)\n",
" return W1, b1, W2, b2\n",
"\n",
"def neural_network(W1, b1, W2, b2, X): \n",
" Z1 = W1 @ X + b1 # Pre-activation (logits) for hidden layer\n",
" H = relu(Z1) # Post-activation for hidden layer\n",
" Z2 = W2 @ H + b2 # Final output (logits)\n",
" return Z2"
]
},
{
"cell_type": "markdown",
"id": "d4c784f4",
"metadata": {},
"source": [
"Endelig introducerer vi nogle hjælpefunktioner til at plotte grafen:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "06a7c89f",
"metadata": {},
"outputs": [],
"source": [
"# 2D grid evaluation \n",
"def eval_on_grid(W1, b1, W2, b2, xlim=(-2,2), ylim=(-2,2), res=120):\n",
" x1_vals = np.linspace(xlim[0], xlim[1], res)\n",
" x2_vals = np.linspace(ylim[0], ylim[1], res)\n",
" X1g, X2g = np.meshgrid(x1_vals, x2_vals, indexing='xy')\n",
" XY_grid = np.stack([X1g.ravel(), X2g.ravel()], axis=0) # (2, res*res)\n",
" Z_out = neural_network(W1, b1, W2, b2, XY_grid).reshape(X1g.shape) # (res, res)\n",
" return X1g, X2g, Z_out\n",
"\n",
"def plot_contours(W1, b1, W2, b2, levels=10, title=\"\", xlim=(-2,2), ylim=(-2,2), res=200):\n",
" X1g, X2g, Z_out = eval_on_grid(W1, b1, W2, b2, xlim, ylim, res)\n",
" plt.figure()\n",
" cs = plt.contour(X1g, X2g, Z_out, levels=levels)\n",
" plt.clabel(cs, inline=True, fontsize=8)\n",
" plt.title(title if title else \"Contour of Φ\")\n",
" plt.xlabel(\"x1\"); plt.ylabel(\"x2\")\n",
" plt.axis(\"equal\")\n",
" plt.show()"
]
},
{
"cell_type": "markdown",
"id": "a20b0ece",
"metadata": {},
"source": [
"### a: Netværket når n=3"
]
},
{
"cell_type": "markdown",
"id": "fe41a7a8",
"metadata": {},
"source": [
"> Opbyg et netværk af formen ($2\\to 3\\to 1$) i Python, hvor du vælger følgende vægte:\n",
"\n",
"\\begin{equation*}\n",
"W_1 =\n",
"\\begin{bmatrix}\n",
"1 & -1\\\\[4pt]\n",
"2 & 0\\\\[4pt]\n",
"-1 & 2\n",
"\\end{bmatrix},\\qquad\n",
"b_1 =\n",
"\\begin{bmatrix}\n",
"0\\\\[4pt]\n",
"1\\\\[4pt]\n",
"-1\n",
"\\end{bmatrix},\\qquad\n",
"W_2 =\n",
"\\begin{bmatrix}\n",
"1 & -2 & 1\n",
"\\end{bmatrix},\\qquad\n",
"b_2 =\n",
"\\begin{bmatrix}\n",
"0\n",
"\\end{bmatrix}.\n",
"\\end{equation*}\n",
"\n",
"```{note}\n",
"Ordet \"vægte\" bruges her blot om parametrene (tallene) i matricerne $W_\\ell$ og i vektorerne $b_\\ell$. Normalt kaldes $W_\\ell$ for \"the weight matrix\" mens $b_\\ell$ kaldes for \"the bias\". Det er disse parametre man ændrer på når man træner et netværk ved hjælp af gradient-metoden. \n",
"```\n",
"\n",
"> Tjek at dit netværk giver værdien $-4$ når du kalder `neural_network(W1, b1, W2, b2, np.array([[1.],[-1.]]))`. Plot derefter grafen som 3D-overflade $z=\\Phi(x_1,x_2)$ for netværket med præcis disse $W_\\ell$ matricer og $b_\\ell$ vektorer. Nedenfor plottes grafen for et \"tilfældigt\" netværk:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e016d422",
"metadata": {},
"outputs": [],
"source": [
"# Sample random weights for a 2 -> 3 -> 1 network\n",
"W1, b1, W2, b2 = sample_weights(3)\n",
"\n",
"# Single test input (column vector)\n",
"x_test = np.array([[1.],[-1.]]) # shape (2,1)\n",
"z2_test = neural_network(W1, b1, W2, b2, x_test)\n",
"print(\"neural_network output for x = [[1.],[-1.]] ->\", z2_test.ravel())\n",
"\n",
"# A few more test points (shape (2, N))\n",
"X_check = np.array([[0., 1., 0., 1.],\n",
" [0., 0., 1., 1.]])\n",
"z2_check = neural_network(W1, b1, W2, b2, X_check)\n",
"print(\"inputs:\\n\", X_check.T)\n",
"print(\"outputs:\\n\", z2_check.ravel())\n",
"\n",
"# Visualize using the helper functions already defined above\n",
"X1g, X2g, Z_out = eval_on_grid(W1, b1, W2, b2)\n",
"plot3d_list(X1g.flatten(), X2g.flatten(), Z_out.flatten() , xlabel=\"x1\", ylabel=\"x2\", title=\"2→3→1 net\")\n",
"plot_contours(W1, b1, W2, b2, levels=20, title=\"Konturplot: 2→3→1 net\")"
]
},
{
"cell_type": "markdown",
"id": "0ddb7833",
"metadata": {},
"source": [
"\n",
"Svar
\n",
"\n",
"```{code} \n",
"# Deterministic integer weights for a 2 -> 3 -> 1 network\n",
"W1 = np.array([[ 1., -1.],\n",
" [ 2., 0.],\n",
" [-1., 2.]])\n",
"b1 = np.array([[0.],[1.],[-1.]])\n",
"W2 = np.array([[1., -2., 1.]])\n",
"b2 = np.array([[0.]])\n",
"```\n",
"\n",
" \n",
"\n",
"> Hvor mange “knæk” kan du se i niveaukurverne? Hvorfor er overfladen stykkevist plan? Hvor mange lineære stykker er overfladen sammensat af? \n",
"\n",
"\n",
"Svar
\n",
"\n",
"Du bør kunne se (op til) tre \"knæk\" i hver niveaukurve. Hvert knæk stammer fra præcis én neuron i det skjulte lag.\n",
"\n",
"1. Hvorfor opstår et \"knæk\"?\n",
"1. Hvorfor er overfladen stykkevist plan?\n",
"1. Hvad er antallet af regioner?\n",
"\n",
"Svar på 1. Hver neuron, $k$, i det skjulte lag beregner først en lineær funktion af inputtet: $\\mathbf{w_k} \\cdot \\mathbf{x} + b_k$. Resultatet af denne beregning sendes ind i ReLU-funktionen. Da $\\text{ReLU}$ kun er aktiv for positive værdier, \"tænder\" eller \"slukker\" neuronen ved den grænse, hvor dens input er nul. Denne grænse er en linje i input-planen, som er defineret ved ligningen:\n",
"\n",
"$$\n",
"\\mathbf{w}_k \\cdot \\mathbf{x} + b_k = 0\n",
"$$ \n",
"\n",
"Når input-punktet $\\mathbf{x}$ krydser denne linje, ændres outputtet fra den pågældende neuron brat fra $0$ til en lineær funktion (eller omvendt). Dette skaber et \"knæk\" i den samlede funktion. Med $n=3$ neuroner er der altså tre sådanne linjer, der skaber knæk i overfladen.\n",
"\n",
"Svar på 2. De $n$ linjer opdeler input-planen i forskellige regioner. Inden for enhver af disse regioner er aktiveringsmønstret for alle neuroner fast (hver neuron er enten konstant \"tændt\" eller \"slukket\"). Når aktiveringsmønstret er fast, opfører hele netværket sig som en simpel affin funktion (en lineær transformation plus en konstant). Grafen for en affin funktion er et plan. Derfor består netværkets samlede graf af forskellige plane \"stykker\", der er sat sammen ved knæk-linjerne.\n",
"\n",
"Svar på 3. Antal regioner: Med $n=3$ linjer kan planet maksimalt opdeles i 7 forskellige lineære regioner. Dette er et klassisk resultat fra kombinatorisk geometri. Prøv at tegne 3 tilfældige linjer på et stykke papir og tæl antallet af regioner mellem linjerne. \n",
"\n",
" "
]
},
{
"cell_type": "markdown",
"id": "2b5adc0c",
"metadata": {},
"source": [
"### b: Netværket når n er lavere og højere"
]
},
{
"cell_type": "markdown",
"id": "2c5e2d1a",
"metadata": {},
"source": [
"> Kør samme visualisering for $n=1$ og $n=8$ og sammenlign den visuelle kompleksitet af grafen for netværket $\\Phi(x_1,x_2)$. Restriktionen af funktionen $\\Phi(x_1,x_2)$ langs en linje i planet $x_2 = a x_1 + b$ vil være stykkevis lineær funktion af een variabel, nemlig $x_1$. Men hvordan afhænger antallet af linjestykker sig når $n$ ændres?"
]
},
{
"cell_type": "markdown",
"id": "840d2060",
"metadata": {},
"source": [
"\n",
"Svar
\n",
"\n",
"- Når $n$ vokser øges antallet af \"knæk\" i niveaukurverne og overfladens visuelle kompleksitet (flere lineære patcher). \n",
"- På et 1D-snit gennem planet kan hver skjult neuron bidrage med højst ét skæringspunkt, så antallet af skæringer ≤ $n$, og dermed antallet af lineære stykker langs snittet ≤ $n+1$.\n",
"\n",
" "
]
},
{
"cell_type": "markdown",
"id": "884a8cdb",
"metadata": {},
"source": [
"## 6: Træning af et Neuralt Netværk"
]
},
{
"cell_type": "markdown",
"id": "74c76c9c",
"metadata": {},
"source": [
"Vi har nu set på alle de nødvendige byggeblokke: digitale billeder som vektorer, ReLU-funktionen, gradient-metoden og strukturen af et neuralt netværk. I denne afsluttende opgave samler vi det hele for at **træne** et neuralt netværk fra bunden til at genkende de håndskrevne tal fra `digits`-datasættet.\n",
"\n",
"Vi initialiserer vægtene tilfældigt og derefter lader vi gradientmetoden iterativt forbedre dem."
]
},
{
"cell_type": "markdown",
"id": "375508d2",
"metadata": {},
"source": [
"### a: Problemstilling\n",
"\n",
"Målet er at finde de optimale vægte $(W_1, b_1, W_2, b_2)$ for et shallow neuralt netværk,\n",
"$\\Phi: \\mathbb{R}^{64} \\to \\mathbb{R}^{10}$, der minimerer en **tabsfunktion**. Tabsfunktionen måler, hvor \"forkerte\" netværkets forudsigelser er i forhold til de sande labels.\n",
"\n",
"### b: Netværkets Funktion\n",
"\n",
"Vores netværk har ét skjult lag og er givet ved funktionen:\n",
"\n",
"\\begin{equation*}\n",
"\\Phi(\\mathbf{x}) = \\text{softmax}\\left( W_2 \\cdot \\text{ReLU}(W_1 \\mathbf{x} + \\mathbf{b}_1) + \\mathbf{b}_2 \\right)\n",
"\\end{equation*}\n",
"\n",
"hvor:\n",
"- $\\mathbf{x} \\in \\mathbb{R}^{64}$ er det \"flade\" input-billede.\n",
"- $W_1, \\mathbf{b}_1, W_2, \\mathbf{b}_2$ er vægtmatricer og bias-vektorer, som vi skal optimere.\n",
"- `ReLU` er aktiveringsfunktionen for det skjulte lag.\n",
"- `softmax` omdanner outputtet til en sandsynlighedsfordeling over de 10 cifre.\n",
"\n",
"### c: Input vs. Parametre: En Vigtig Forskel\n",
"\n",
"Det er afgørende at skelne mellem to forskellige roller, som \"variable\" spiller i denne proces:\n",
"\n",
"1. **Modellens Input (`x`):** Når vi tænker på den færdigtrænede netværks-funktion, $\\Phi(\\mathbf{x})$, er $\\mathbf{x}$ (billed-vektoren) den *variabel*, der kommer ind i funktionen. Parametrene $W_1, \\mathbf{b}_1, W_2, \\mathbf{b}_2$ er på dette tidspunkt *faste konstanter*, der definerer selve funktionen. Vi har én specifik funktion, $\\Phi$, som vi kan kalde med forskellige input-billeder.\n",
"\n",
"2. **Modellens Parametre (`W`, `b`):** Når vi *træner* modellen, bytter rollerne om. Her er vores mål at finde de *bedste* værdier for parametrene. Derfor er det nu $W_1, \\mathbf{b}_1, W_2, \\mathbf{b}_2$, der er de *variable*, som vi justerer på. Tabsfunktionen $L$, der indføres nedenfor, er en funktion af disse parametre, $L(W_1, \\mathbf{b}_1, \\dots)$, hvor træningsdataene ($\\mathbf{x}$ og $\\mathbf{y}$) holdes konstante. Her er $\\mathbf{x}$ input-billedet, og $\\mathbf{y}$ er den tilhørende korrekte label (f.eks. tallet 7), som modellen skal lære at forudsige.\n",
"\n",
"Gradient-metoden opererer på tabsfunktionen. Den behandler altså modellens *parametre* som variable og justerer dem for at minimere tabet. Når træningen er færdig, \"låser\" vi disse optimale parametre, og resultatet er vores færdige netværks-funktion, $\\Phi(\\mathbf{x})$, der er klar til at modtage nye billeder som input."
]
},
{
"cell_type": "markdown",
"id": "266899fa",
"metadata": {},
"source": [
"### d: One-hot encoding\n",
"\n",
"Vores neurale netværk producerer et output i form af en sandsynlighedsvektor i $\\mathbb{R}^{10}$. For at kunne beregne tabet (fejlen) skal vi sammenligne denne output-vektor med den *sande* label. Den sande label, f.eks. tallet `3`, skal derfor også repræsenteres som en vektor i $\\mathbb{R}^{10}$.\n",
"\n",
"Dette gøres ved hjælp af **one-hot encoding**:\n",
"En label $y$ omdannes til en vektor af længde $K$ (antallet af klasser), som består af lutter nuller, undtagen på den position, der svarer til klassens indeks, hvor der står et 1-tal.\n",
"\n",
"**Eksempel:** For vores problem med $K=10$ klasser (cifrene 0-9), vil en label $y=3$ blive til one-hot vektoren:\n",
"\n",
"\\begin{equation*}\n",
"\\mathbf{y} = [0, 0, 0, 1, 0, 0, 0, 0, 0, 0]\n",
"\\end{equation*}\n",
"\n",
"hvor 1-tallet står på indeks 3 (husk 0-baseret indeksering).\n",
"\n",
"> Hvordan ser one-hot vektoren ud for en label $y=7$?\n",
"\n",
"\n",
"Svar
\n",
"For y=7 er one-hot vektoren:\n",
"\n",
"\\begin{equation*}\n",
"\\mathbf{y} = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0]\n",
"\\end{equation*}\n",
"\n",
" \n",
"\n",
"For at lave one-hot encoding af labels i Python kan man bruge `OneHotEncoder` fra `sklearn`. \n",
"Et eksempel:\n",
"\n",
"```python\n",
"enc = OneHotEncoder(sparse_output=False)\n",
"Y = enc.fit_transform(y.reshape(-1, 1))\n",
"```\n",
"\n",
"Her omdannes vektoren `y` med klasselabels til en one-hot matrix `Y`, hvor hver række svarer til én one-hot encoding for et billede.\n",
"\n",
"### e: Tabsfunktionen (Cross-Entropy Loss)\n",
"\n",
"For at måle hvor \"forkert\" netværkets forudsigelse er, bruger vi en tabsfunktion. For klassifikationsproblemer er **Cross-Entropy Loss** det mest almindelige valg.\n",
"\n",
"For et enkelt træningseksempel $(\\mathbf{x}, \\mathbf{y})$, hvor $\\mathbf{y}$ er den sande one-hot label og $\\mathbf{p} = \\Phi(\\mathbf{x})$ er netværkets forudsagte sandsynligheder, er tabet givet ved:\n",
"\\begin{equation*}\n",
"L(\\mathbf{p}, \\mathbf{y}) = - \\sum_{i=0}^{9} y_i \\log(p_i)\n",
"\\end{equation*}\n",
"Vi beregner gennemsnittet af dette tab over alle billeder i vores træningssæt.\n",
"\n",
"\n",
"> **Forståelse af Cross-Entropy**\n",
">\n",
"> Lad os sige, at den sande label er $y=3$, så den tilhørende one-hot vektor er $\\mathbf{y} = [0, 0, 0, 1, 0, \\dots]$. I dette tilfælde reduceres tabsfunktionen til $L = -1 \\cdot \\log(p_3)$.\n",
">\n",
"> Hvorfor er dette en fornuftig måde at måle fejl på? Tænk på, hvad der sker med værdien af $L$, når netværkets forudsagte sandsynlighed for klasse 3, $p_3$, er:\n",
"> 1. Meget tæt på 1 (en korrekt og sikker forudsigelse).\n",
"> 2. Meget tæt på 0 (en forkert og sikker forudsigelse).\n",
"\n",
"\n",
"Svar
\n",
"\n",
"Funktionen $-\\log(p)$ har en ideel opførsel for en tabsfunktion:\n",
"1. **Når $p_3 \\to 1$ (korrekt forudsigelse):** Går $-\\log(p_3) \\to 0$. Netværket straffes altså minimalt for en korrekt og sikker forudsigelse.\n",
"2. **Når $p_3 \\to 0$ (forkert forudsigelse):** Går $-\\log(p_3) \\to \\infty$. Netværket straffes **ekstremt hårdt**, hvis det er meget sikkert på en forkert klasse.\n",
"\n",
"Denne egenskab tvinger effektivt netværket til at øge sandsynligheden for den korrekte klasse.\n",
"\n",
" \n",
"\n",
"> **Alternativ Tabsfunktion**\n",
">\n",
"> Kunne vi ikke have brugt en mere simpel tabsfunktion, som f.eks. den **kvadratiske fejl (Mean Squared Error)**, vi kender fra lineær regression?\n",
">\n",
"> $$\n",
" L_{MSE}(\\mathbf{p}, \\mathbf{y}) = \\sum_{i=0}^{9} (p_i - y_i)^2\n",
" $$\n",
">\n",
"\n",
"\n",
"Svar
\n",
"Man *kan* godt bruge kvadratisk fejl, men det er generelt en dårlig idé til klassifikationsproblemer med en softmax-output. Hovedårsagen er relateret til **gradienterne**:\n",
"\n",
"1. **Problem med Kvadratisk Fejl:** Hvis netværket er meget forkert på den (f.eks. forudsiger $p_3 \\approx 0$ når den sande label er $y_3=1$), kan gradienten af den kvadratiske fejl blive meget lille. Det betyder, at netværket \"lærer\" ekstremt langsomt, selvom fejlen er stor. Dette fænomen kaldes *vanishing gradients*.\n",
"\n",
"2. **Fordel ved Cross-Entropy i kombination med Softmax:** Ja, denne simple gradient er et resultat af den matematiske \"synergi\" mellem Cross-Entropy og Softmax. Når man bruger kædereglen til at finde gradienten af tabet med hensyn til outputtet $z_i$ *før* softmax-funktionen, simplificerer udtrykket sig til:\n",
"\n",
"$$\n",
"\\frac{\\partial L}{\\partial z_i} = p_i - y_i\n",
"$$\n",
"\n",
"hvor $p_i$ er outputtet *efter* softmax. Dette er altså blot forskellen mellem den forudsagte og den sande sandsynlighed. Hvis fejlen er stor, er gradienten også stor, og netværket lærer hurtigt. \n",
"\n",
" \n",
"\n",
"Når vi senere skal implementere tabet i Python, skal vi implementere følgende to trin:\n",
"\n",
"1. **Softmax:** Vi omdanner outputtet fra netværkets sidste lag (såkaldte logits) til en sandsynlighedsfordeling.\n",
"2. **Cross-Entropy Loss:** Vi måler fejlen ved at tage den negative logaritme af sandsynligheden for den korrekte klasse og derefter gennemsnittet over alle træningsbilleder.\n",
"\n",
"Et eksempel:\n",
"\n",
"```python\n",
"# Softmax: omdanner logits til sandsynligheder for hver klasse\n",
"exp_Z = np.exp(Z)\n",
"# probs er softmax-outputtet\n",
"probs = exp_Z / np.sum(exp_Z, axis=0, keepdims=True)\n",
"# Cross-entropy loss:\n",
"# Y_train er one-hot encoding matrix\n",
"loss = np.mean(-np.log(probs[Y_train.argmax(axis=0), np.arange(m_train)]))\n",
"# For at beregne tabet, skal vi bruge sandsynligheden for den korrekte klasse for hvert eksempel.\n",
"# probs[Y_train.argmax(axis=0), np.arange(m_train)] er en effektiv måde at udvælge disse sandsynligheder på.\n",
"# Y_train.argmax(axis=0) giver række-indekserne (de sande klasser).\n",
"# np.arange(m_train) giver søjle-indekserne (eksempel-nummeret).\n",
"```\n",
"\n",
"\n",
"### f: Træningsprocessen\n",
"\n",
"Koden nedenfor implementerer gradient-metoden (også kaldet *backpropagation* og *gradient descent*) ved at gentage følgende tre skridt i en løkke:\n",
"\n",
"1. **Forward Pass:** Send alle træningsbilleder gennem netværket for at beregne forudsigelserne (`probs`) og det samlede tab (`loss`).\n",
"2. **Backward Pass (Backpropagation):** Beregn gradienten af tabsfunktionen $\\nabla L$ med hensyn til hver eneste parameter i netværket. Dette gøres effektivt ved hjælp af kædereglen, hvor fejlen \"propagateres\" baglæns gennem netværket.\n",
"3. **Parameter Update:** Juster hver parameter ved at tage et lille skridt i den negative gradient-retning.\n",
"\n",
"Efter at have gentaget disse skridt mange gange, testes det trænede netværks nøjagtighed på et separat testsæt, som det aldrig har set før."
]
},
{
"cell_type": "markdown",
"id": "c2a49a63",
"metadata": {},
"source": [
"### g: Kort om Holdout-metoden\n",
"\n",
"Når man træner et neuralt netværk, er det afgørende at teste modellen på billeder den ikke har set under træningen. Holdout-metoden opdeler derfor datasættet i to dele.\n",
"\n",
"Hvis vi tester på de samme data, som vi træner på, risikerer vi at modellen bare husker træningsbillederne i stedet for at lære generelle mønstre. Så siger man, at modellen 'overfitter' til træningsdataen. Et separat testsæt afslører, hvor godt modellen kan generalisere.\n",
"\n",
"**Træningssæt:**\n",
"- Modellen lærer fra dette datasæt\n",
"- Her foretages forward pass, backpropagation og vægtopdateringer\n",
"\n",
"**Testsæt:**\n",
"- Modellen ser dette datasæt først til allersidst\n",
"- Giver et retvisende billede af generaliseringsevnen\n",
"\n",
"Kort sagt: Træn på ét datasæt - evaluer på et andet.\n",
"\n",
"I Python kan man splitte et datasæt til et **træningssæt** og **testsæt** med funktionen `train_test_split(X, Y, test_size=0.2, random_state=42)`, hvor `random_state` fastlåser, hvordan vi splitter dataen, så man kan genskabe sine resultater senere."
]
},
{
"cell_type": "markdown",
"id": "54ebf994",
"metadata": {},
"source": [
"(opg:dnn-implementering)=\n",
"### h: Implementeringen af modellen"
]
},
{
"cell_type": "markdown",
"id": "f24b9b99",
"metadata": {},
"source": [
"Vi er nu klar til at teste og træne netværket. Vi bruger ligesom i opgaven [](opg:digitalt-billede-vektor) sklearn's digits-datasæt, som består af 8x8 pixels billeder af håndskrevne tal:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "2ad8abd2",
"metadata": {},
"outputs": [],
"source": [
"# Load small digits dataset (8x8, built into sklearn)\n",
"X, y = load_digits(return_X_y=True)\n",
"X = X / 16.0\n",
"enc = OneHotEncoder(sparse_output=False)\n",
"Y = enc.fit_transform(y.reshape(-1, 1))\n",
"print(f\"Original data shapes: X={X.shape}, Y={Y.shape}\")"
]
},
{
"cell_type": "markdown",
"id": "e72d1cab",
"metadata": {},
"source": [
"Herefter bruger vi Holdout-metoden til at reservere 20 procent af billederne til test af modellen ved at benytte funktionen `train_test_split()`. Derudover initialiserer vi vægtmatricerne og bias-vektorerne med tilfældige tal trukket fra en normalfordelingen."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4f6f787c",
"metadata": {},
"outputs": [],
"source": [
"# Split data, and TRANSPOSE X to fit the W @ X convention\n",
"# X shape becomes (features, samples) instead of (samples, features)\n",
"X_train_orig, X_test_orig, Y_train, Y_test = train_test_split(X, Y, test_size=0.2, random_state=42)\n",
"X_train, X_test = X_train_orig.T, X_test_orig.T\n",
"Y_train, Y_test = Y_train.T, Y_test.T\n",
"\n",
"# Get number of samples\n",
"m_train = X_train.shape[1]\n",
"m_test = X_test.shape[1]\n",
"\n",
"# Tiny 1-hidden-layer NN with (neurons, features) shape for weights\n",
"# W1: 32 neurons, 64 input features -> (32, 64)\n",
"# b1: bias for each of the 32 neurons -> (32, 1)\n",
"# W2: 10 neurons, 32 hidden features -> (10, 32)\n",
"# b2: bias for each of the 10 neurons -> (10, 1)\n",
"W1 = np.random.randn(32, 64) * 0.1\n",
"b1 = np.zeros((32, 1))\n",
"W2 = np.random.randn(10, 32) * 0.1\n",
"b2 = np.zeros((10, 1))"
]
},
{
"cell_type": "markdown",
"id": "6a09646c",
"metadata": {},
"source": [
"Vi kører 100 iterationer af gradient-metoden og tester herefter modellen på det tilbageholdte testsæt:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e2be797b",
"metadata": {},
"outputs": [],
"source": [
"lr = 0.1\n",
"for epoch in range(100):\n",
" # forward pass with W @ X\n",
" Z1 = W1 @ X_train + b1\n",
" H = np.maximum(0, Z1) # Hidden layer activations, shape (32, m_train)\n",
" Z2 = W2 @ H + b2 # Output logits, shape (10, m_train)\n",
" \n",
" # Softmax applied column-wise (axis=0) for each sample\n",
" exp_Z2 = np.exp(Z2)\n",
" probs = exp_Z2 / np.sum(exp_Z2, axis=0, keepdims=True)\n",
" \n",
" # Cross-entropy loss\n",
" loss = np.mean(-np.log(probs[Y_train.argmax(axis=0), np.arange(m_train)]))\n",
" \n",
" # backward pass (backpropagation)\n",
" dZ2 = probs - Y_train\n",
" dW2 = (1/m_train) * dZ2 @ H.T\n",
" db2 = (1/m_train) * np.sum(dZ2, axis=1, keepdims=True)\n",
" \n",
" dH = W2.T @ dZ2\n",
" dZ1 = dH * (Z1 > 0) # ReLU gradient\n",
" dW1 = (1/m_train) * dZ1 @ X_train.T\n",
" db1 = (1/m_train) * np.sum(dZ1, axis=1, keepdims=True)\n",
" \n",
" # update parameters\n",
" W1 -= lr * dW1\n",
" b1 -= lr * db1\n",
" W2 -= lr * dW2\n",
" b2 -= lr * db2\n",
"\n",
"# test accuracy\n",
"Z1_test = W1 @ X_test + b1\n",
"H_test = np.maximum(0, Z1_test)\n",
"Z2_test = W2 @ H_test + b2\n",
"pred = np.argmax(Z2_test, axis=0) # Get predicted class for each column (sample)\n",
"acc = np.mean(pred == Y_test.argmax(axis=0))\n",
"print(f\"Accuracy: {acc:.3f}\")"
]
},
{
"cell_type": "markdown",
"id": "6af52adc",
"metadata": {},
"source": [
"> Hvad sker der med test-præcisionen, hvis du ændrer initialiseringen af vægtmatricerne og bias-vektorerne? Hvor stor en ændring skal der til, før at præcisionen bliver betydelig dårligere? Hvad tror du der sker hvis `for epoch in range(100):` ovenfor ændres til `for epoch in range(10):` eller `for epoch in range(1000):`? (Prøv)"
]
},
{
"cell_type": "markdown",
"id": "05036bbf",
"metadata": {},
"source": [
"Vi har nu set, hvordan man træner et neuralt netværk og måler dets præstation på hele test-sættet. Men for at forstå hvad modellen laver, er det nyttigt at kigge på klassificeringen af et enkelt billede. Koden nedenfor viser sandsynlighedsoutputtet, når det første test-billede køres igennem modellen:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a44ca95d",
"metadata": {},
"outputs": [],
"source": [
"# Take a single image from the test set\n",
"sample_idx = 0 # First test image\n",
"single_image = X_test[:, sample_idx:sample_idx+1] # Keep shape (64, 1)\n",
"\n",
"# Run the image through the network\n",
"Z1_single = W1 @ single_image + b1\n",
"H_single = np.maximum(0, Z1_single)\n",
"Z2_single = W2 @ H_single + b2\n",
"\n",
"# Find the predicted class\n",
"predicted_class = np.argmax(Z2_single, axis=0)[0]\n",
"actual_class = np.argmax(Y_test[:, sample_idx])\n",
"\n",
"# Display the image\n",
"plt.figure(figsize=(6, 3))\n",
"\n",
"# Left: show the actual image\n",
"plt.subplot(1, 2, 1)\n",
"# Reshape back to 8x8 and display\n",
"image_2d = X_test_orig[sample_idx].reshape(8, 8)\n",
"plt.imshow(image_2d, cmap='gray')\n",
"plt.title(f'Image {sample_idx}\\nActual: {actual_class}')\n",
"plt.axis('off')\n",
"\n",
"# Right: show probabilities\n",
"plt.subplot(1, 2, 2)\n",
"probabilities = np.exp(Z2_single) / np.sum(np.exp(Z2_single))\n",
"classes = range(10)\n",
"colors = ['red' if i == actual_class else ('green' if i == predicted_class else 'blue') for i in classes]\n",
"\n",
"plt.bar(classes, probabilities.flatten(), color=colors)\n",
"plt.xlabel('Class')\n",
"plt.ylabel('Probability')\n",
"plt.title(f'Predicted: {predicted_class}\\nCorrect: {predicted_class == actual_class}')\n",
"plt.xticks(classes)\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "7ebaddd8",
"metadata": {},
"source": [
"> Kan du finde et billede, der misklassificeres af modellen?\n",
"\n",
"\n",
"Hint
\n",
"\n",
"Næste kodecelle finder automatisk eksempler på fejlklassificerede billeder, så prøv at kør den.\n",
"\n",
" "
]
},
{
"cell_type": "markdown",
"id": "a085659a",
"metadata": {},
"source": [
"Nedenstående kode identificerer de første 5 fejlklassificerede billeder sammen med sandsynlighedsoutputtet for modellen:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "da621213",
"metadata": {},
"outputs": [],
"source": [
"# Get all predictions on the test set\n",
"Z1_test = W1 @ X_test + b1 # First layer pre-activation\n",
"H_test = np.maximum(0, Z1_test) # ReLU activation\n",
"Z2_test = W2 @ H_test + b2 # Output layer logits\n",
"test_predictions = np.argmax(Z2_test, axis=0) # Predicted classes\n",
"true_labels = np.argmax(Y_test, axis=0) # True classes\n",
"\n",
"# Find where the model makes mistakes\n",
"incorrect_indices = np.where(test_predictions != true_labels)[0]\n",
"\n",
"# Display the first 5 misclassified images\n",
"plt.figure(figsize=(12, 8))\n",
"\n",
"for i, idx in enumerate(incorrect_indices[:5]):\n",
" # Show the image\n",
" plt.subplot(2, 5, i+1)\n",
" image_2d = X_test_orig[idx].reshape(8, 8) # Reshape to 8x8\n",
" plt.imshow(image_2d, cmap='gray')\n",
" plt.title(f'Image {idx}\\nTrue: {true_labels[idx]}\\nPred: {test_predictions[idx]}')\n",
" plt.axis('off')\n",
" \n",
" # Show probability distribution\n",
" plt.subplot(2, 5, i+6)\n",
" single_image = X_test[:, idx:idx+1] # Get single image keeping shape\n",
" Z1_single = W1 @ single_image + b1\n",
" H_single = np.maximum(0, Z1_single)\n",
" Z2_single = W2 @ H_single + b2\n",
" probabilities = np.exp(Z2_single) / np.sum(np.exp(Z2_single)) # Softmax\n",
" \n",
" classes = range(10)\n",
" # Color coding: red=true, green=predicted, blue=other\n",
" colors = ['red' if c == true_labels[idx] else \n",
" ('green' if c == test_predictions[idx] else 'blue') \n",
" for c in classes]\n",
" \n",
" plt.bar(classes, probabilities.flatten(), color=colors)\n",
" plt.xticks(classes)\n",
" plt.xlabel('Class')\n",
" plt.ylabel('Probability')\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "5cc97f87",
"metadata": {},
"source": [
"> Når du kigger på de fejlklassificerede eksempler og deres sandsynlighedsfordelinger, hvilke mønstre lægger du mærke til? Prøv eventuelt at ændre hvilke fejlklassificerede billeder, der vises, og undersøg om disse mønstre er gennemgående."
]
},
{
"cell_type": "markdown",
"id": "2ccca6aa",
"metadata": {},
"source": [
"### Ekstra-spørgsmål (frivillig)"
]
},
{
"cell_type": "markdown",
"id": "1281e0d6",
"metadata": {},
"source": [
"1. Er man garanteret til at finde det ægte minimumspunkt for tabsfunktionen $L$ ved gradient-metoden brugt oven for?\n",
"1. Hvorfor kan man ikke bare finde minimumspunktet for tabsfunktionen $L$ direkte? Kan man plotte funktionen $L$ og visuelt aflæse minimum?\n",
"1. Netværkets funktion $\\Phi$ er bestemt af dets vægtmatricer ($W_1, W_2$) og bias-vektorer ($b_1, b_2$). Beregn det samlede antal af disse justerbare parametre i $\\Phi$? \n",
"1. Kan du matematisk verificere udregningen af nogle af de centrale gradienter i `backward pass`-delen af koden (f.eks. `dZ2` og `dW2`) i `for`-løkken `for epoch in range(100):` i Python-koden ovenfor?"
]
}
],
"metadata": {
"jupytext": {
"text_representation": {
"extension": ".md",
"format_name": "myst",
"format_version": 0.13,
"jupytext_version": "1.17.2"
}
},
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.0"
},
"source_map": [
12,
17,
21,
37,
41,
45,
48,
53,
56,
73,
76,
93,
97,
110,
115,
119,
134,
143,
146,
150,
152,
187,
191,
195,
201,
213,
216,
240,
245,
256,
265,
269,
296,
302,
348,
352,
354,
418,
422,
445,
467,
472,
485,
501,
505,
536,
540,
575,
594,
634,
638,
642,
653,
658,
664,
695,
817,
837,
842,
846,
853,
857,
877,
881,
919,
923,
927,
966,
975,
979,
1022,
1026,
1030
]
},
"nbformat": 4,
"nbformat_minor": 5
}